﻿' ------------------------------------------------------------------------------
' This class provides metadata about a jpeg file to the client. The
' metadata is read from the Exif GPS block contained in a jpeg image.
'
' The heart of this class is the ProcessMetadata() method. For each
' MetaSpec, it loads the metadata object from the image by passing
' the WicQuery in the MetaSpect to the BitmapMetadata.GetQuery() method.
' This .NET method is a wrapper around the Windows Image Components
' (WIC). WIC uses the path-like WicQuery to locate the metadata element
' within the image file and returns what it finds as an Object instance.
' ProcessMetadata calls appropriate methods to decode each object
' by the MetaSpec to determine the desired type/method.
'
' The majority of the metadata elements are direct analogs of the
' elements contained in the Exif spec. Other metadata elements have been
' added which are derived from the elements in the spec, such as the
' Doubles that store the latitude and longitude, and other similar
' types derived from various raw Exif storage representations.
' 
' For detailed information on the Exif datatypes and how they are
' intepreted and converted, see the Exif spec in document "JEITA
' CP-3451" entitled "Exchangeable image file format for digital still
' cameras: Exif Version 2.2".
' ------------------------------------------------------------------------------
' This file is part of the SQL Server 2008 Technology Demonstration. This
' application was designed and coded by Eric Weitzman, LandTime Inc, for
' Microsoft Corporation. Contact Ed Katibah at Microsoft about this demo.
' Copyright © 2008 Microsoft Corporation. All rights reserved.
' ------------------------------------------------------------------------------

Imports System.Windows.Media.Imaging

Public Class ExifMetadata

    Private m_metadata As Dictionary(Of String, Object)
    Private m_fname As String = Nothing
    Private m_order As EnumCoordinateOrder = EnumCoordinateOrder.eUnknown

    Private Shared m_metamapper As MetaMapper = Nothing

#Region " singleton MetaMapper "
    Public Shared Sub InitializeClass()
        If m_metamapper IsNot Nothing Then Return
        m_metamapper = New MetaMapper(SpatialAlbum.Resources.My.Resources.GpsExifMappings)
    End Sub

    Public Shared Property MetaMapper() As MetaMapper
        Get
            InitializeClass()
            Return m_metamapper
        End Get
        Set(ByVal value As MetaMapper)
            m_metamapper = value
        End Set
    End Property
#End Region

#Region " properties "
    Public ReadOnly Property Metadata() As Dictionary(Of String, Object)
        Get
            Return m_metadata
        End Get
    End Property
#End Region

    Public Sub New(ByVal fname As String, ByVal order As EnumCoordinateOrder)
        m_fname = fname
        m_metadata = New Dictionary(Of String, Object)
        m_order = order
        LoadMetadata()
    End Sub

    Private Sub LoadMetadata()
        InitializeClass()
        Dim source As BitmapSource = Nothing
        Try
            Dim ifs As New System.IO.FileStream(m_fname, IO.FileMode.Open, IO.FileAccess.Read)
            Dim decoder As New JpegBitmapDecoder(ifs, BitmapCreateOptions.IgnoreImageCache, BitmapCacheOption.Default)
            source = decoder.Frames(0)
        Catch ex As Exception
            ReportException(ex)
            Return
        End Try
        If source Is Nothing OrElse source.Metadata Is Nothing Then Return
        ProcessMetadata(source)
    End Sub

    Private Sub ProcessMetadata(ByVal source As BitmapSource)

        Dim bmeta As BitmapMetadata = CType(source.Metadata, BitmapMetadata)
        If bmeta Is Nothing Then Return

        ' read each metadata element using WIC
        For Each ms As MetaSpec In m_metamapper.MetaSpecs.Values
            If ms.DecoderID <> -1 Then
                Dim ometa As Object = bmeta.GetQuery(ms.WicQuery)
                If ometa IsNot Nothing Then
                    Dim val As Object = Nothing
                    Select Case ms.DecoderID
                        Case 1 : val = ExifVersionToString(CType(ometa, Byte()))
                        Case 2 : val = CType(ometa, String)
                        Case 3 : val = CType(ometa, Integer)
                        Case 4 : val = ExifRationalToFloat(CType(ometa, ULong))
                        Case 5 : val = ExifRational3ToDegrees(CType(ometa, ULong()))
                        Case 6 : val = ExifRational3ToTime(CType(ometa, ULong()))
                        Case 7 : val = ExifCharacterCodedBytesToString(CType(ometa, BitmapMetadataBlob))
                        Case 8 : val = ExifRational3ToString(CType(ometa, ULong()))
                        Case 9 : val = ExifRationalToString(CType(ometa, ULong))
                        Case Else
                            Throw New Exception("ProcessMetadata: invalid decoder in Metaspec, decoder = " & ms.DecoderID)
                    End Select
                    If TypeOf val Is Double Then
                        Dim d As Double = DirectCast(val, Double)
                        If Double.IsInfinity(d) Or Double.IsNaN(d) Or Double.IsNegativeInfinity(d) Or Double.IsPositiveInfinity(d) Then val = Nothing
                    End If
                    If val IsNot Nothing Then m_metadata.Add(ms.MetaName, val)
                End If
            End If
        Next

        ' derive these metadata elements from those read from the source
        For Each ms As MetaSpec In m_metamapper.MetaSpecs.Values
            If ms.DecoderID = -1 Then
                Select Case ms.MetaName
                    Case "WKTLocation" : MakeWKTLocation()
                    Case "Latitude" : MakeLatitude()
                    Case "Longitude" : MakeLongitude()
                    Case "DateTimeOffset" : MakeDateTime()
                End Select
            End If
        Next
    End Sub

    Private Function ExifRational3ToDegrees(ByVal rational() As ULong) As Double
        Dim dms() As Double = ExifRationalToFloat(rational, 3)
        Return dms(0) + dms(1) / 60.0 + dms(2) / 3600.0
    End Function

    Private Function ExifRational3ToString(ByVal rational() As ULong) As String
        Return ExifRationalToString(rational(0)) & ", " & ExifRationalToString(rational(1)) & ", " & ExifRationalToString(rational(2))
    End Function

    Private Function ExifRational3ToTime(ByVal rational() As ULong) As String
        Dim hms() As Double = ExifRationalToFloat(rational, 3)
        Return String.Format("{0:D2}:{1:D2}:{2:d2}", CType(hms(0), Integer), CType(hms(1), Integer), CType(hms(2), Integer))
    End Function

    Private Shared Function ExifRationalToFloat(ByVal rational() As ULong, ByVal n As Integer) As Double()
        Dim retval(n) As Double
        For i As Integer = 0 To n - 1
            retval(i) = ExifRationalToFloat(rational(i))
        Next
        Return retval
    End Function

    ' Rationals are stored as a two signed longs in a single 64-bit word.
    ' The first long is the numerator and the second is the denominator.

    Private Shared Function ExifRationalToFloat(ByVal rational As ULong) As Double
        Dim denominator As Integer = CType(rational >> 32, Integer)
        Dim numerator As Integer = CType((rational And &HFFFFFFFFUL), Integer)
        If denominator = 1 Then Return CType(numerator, Double)
        Return CType(numerator, Double) / CType(denominator, Double)
    End Function

    ' Some rationals are returned as string representations so the precision of the
    ' data can be seen from the denominator.

    Private Shared Function ExifRationalToString(ByVal rational As ULong) As String
        Dim denominator As Integer = CType(rational >> 32, Integer)
        Dim numerator As Integer = CType((rational And &HFFFFFFFFUL), Integer)
        Return numerator.ToString() & "/" & denominator.ToString
    End Function

    Private Shared Function ExifVersionToString(ByVal v() As Byte) As String
        Dim sb As New System.Text.StringBuilder
        sb.Append(v(0)).Append(".").Append(v(1)).Append(".").Append(v(2)).Append(".").Append(v(3))
        Return sb.ToString()
    End Function

    ' Some character strings can be encoded per different encoding standards.
    ' TODO: implement encoding translations into current locale

    Private Function ExifCharacterCodedBytesToString(ByVal blob As BitmapMetadataBlob) As String

        ' 8-byte headers for each supported character code set
        Dim ASCII_HEADER() As Byte = {&H41, &H53, &H43, &H49, &H49, &H0, &H0, &H0}     ' ASCII, ITU-T T.50 IA5
        Dim JIS_HEADER() As Byte = {&H4A, &H49, &H53, &H0, &H0, &H0, &H0, &H0}         ' JIS, JIS X208-1990
        Dim UNICODE_HEADER() As Byte = {&H55, &H4E, &H49, &H43, &H4F, &H44, &H45, &H0} ' Unicode Standard
        Dim UNDEF_HEADER() As Byte = {&H0, &H0, &H0, &H0, &H0, &H0, &H0, &H0}          ' undefined

        Dim b() As Byte = blob.GetBlobValue()

        ' TODO: convert blob to current locale
        If ExifMatchCharacterCodingHeader(ASCII_HEADER, b) Then
        ElseIf ExifMatchCharacterCodingHeader(JIS_HEADER, b) Then
        ElseIf ExifMatchCharacterCodingHeader(UNICODE_HEADER, b) Then
        ElseIf ExifMatchCharacterCodingHeader(UNDEF_HEADER, b) Then
        Else
        End If

        Dim sb As New System.Text.StringBuilder
        For i As Integer = 8 To b.Length - 1
            sb.Append(b(i))
        Next

        Return sb.ToString()

    End Function

    Private Function ExifMatchCharacterCodingHeader(ByVal hdr() As Byte, ByVal b() As Byte) As Boolean
        Assert(b.Length > 8, "invalid length for character-coded string")
        For i As Integer = 0 To 7
            If hdr(i) <> b(i) Then Return False
        Next
        Return True
    End Function

    Private Sub MakeWKTLocation()
        Dim val As String = GetWKTLocation()
        If val IsNot Nothing Then m_metadata.Add("WKTLocation", val)
    End Sub

    Private Sub MakeLatitude()
        Dim val As Double? = GetLatitude()
        If val IsNot Nothing Then m_metadata.Add("Latitude", val)
    End Sub

    Private Sub MakeLongitude()
        Dim val As Double? = GetLongitude()
        If val IsNot Nothing Then m_metadata.Add("Longitude", val)
    End Sub

    Private Sub MakeDateTime()
        Dim val As DateTime? = GetDateTime()
        If val IsNot Nothing Then m_metadata.Add("DateTimeOffset", val)
    End Sub

    ' Create a single DateTime from the two GPS date/time stamps

    Private Function GetDateTime() As DateTime?
        For Each element As String In New String() {"GPSTimeStamp", "GPSDateStamp"}
            If Not m_metadata.ContainsKey(element) Then Return Nothing
        Next

        Dim datestamp As String = CType(m_metadata.Item("GPSDateStamp"), String)
        If datestamp Is Nothing Then Return Nothing
        Dim timestamp As String = CType(m_metadata.Item("GPSTimeStamp"), String)

        Dim ymd() As String = datestamp.Split(":"c)
        Assert(ymd.Length = 3, "invalid date GPSDateStamp")
        Dim hms() As String = timestamp.Split(":"c)
        Assert(ymd.Length = 3, "invalid date GPSTimeStamp")

        Return New DateTime(CType(ymd(0), Integer), CType(ymd(1), Integer), CType(ymd(2), Integer), _
                            CType(hms(0), Integer), CType(hms(1), Integer), CType(hms(2), Integer))

    End Function

    Private Function GetWKTLocation() As String
        Dim lat As Double? = GetLatitude()
        If lat Is Nothing Then Return Nothing
        Dim lon As Double? = GetLongitude()
        If lon Is Nothing Then Return Nothing
        Return CreateWktGeographyPoint(CType(lon, Double), CType(lat, Double), m_order)
    End Function

    Private Function GetLatitude() As Double?
        For Each element As String In New String() {"GPSLatitude", "GPSLatitudeRef"}
            If Not m_metadata.ContainsKey(element) Then Return Nothing
        Next
        Dim lat As Double = CType(m_metadata.Item("GPSLatitude"), Double)
        Dim c As String = CType(m_metadata.Item("GPSLatitudeRef"), String).ToUpper()
        If c = "S" Then lat *= -1 ' latitudes south of the equator are negative
        Return lat
    End Function

    Private Function GetLongitude() As Double?
        For Each element As String In New String() {"GPSLongitude", "GPSLongitudeRef"}
            If Not m_metadata.ContainsKey(element) Then Return Nothing
        Next
        Dim lon As Double = CType(m_metadata.Item("GPSLongitude"), Double)
        Dim c As String = CType(m_metadata.Item("GPSLongitudeRef"), String).ToUpper()
        If c = "W" Then lon *= -1 ' longitudes west of Greenwich are negative
        Return lon
    End Function

End Class
